Skip to content

gguf: release the weight mmap and the split loader's overflow chunks - #347

Merged
CrispStrobe merged 4 commits into
CrispStrobe:mainfrom
mculbert:fix/gguf-release-weight-mapping
Aug 13, 2026
Merged

gguf: release the weight mmap and the split loader's overflow chunks#347
CrispStrobe merged 4 commits into
CrispStrobe:mainfrom
mculbert:fix/gguf-release-weight-mapping

Conversation

@mculbert

Copy link
Copy Markdown
Contributor

core_gguf::load_weights maps the whole GGUF and hands the region to the device on the zero-copy
path. buffer_from_host_ptr has no deallocator parameter, and Metal passes deallocator:nil, so
freeing the backend buffer released the device-side view and left the host mapping in place. The
loader dropped its only handle to a region it still owned — lookup_gpu_mmap, the function that
would find it again, had no caller on any release path.

The result on Apple silicon is that a process holds every weight file it has ever loaded. Two Omni
CTC sessions in one process (3B q8 then 7B q8, serial) peaked at 11.25 GB, the sum of both
models, against 7.73 GB with CRISPASR_GGUF_MMAP=0. The accumulation is per load, not per
file: twenty load/free cycles of a single GGUF left twenty live mappings.

The comment being deleted is wrong, and here is the measurement

gguf_loader.cpp argued the leak was affordable because "on macOS the kernel can still evict
file-backed pages under pressure (they're not anonymous)". That is false for this mapping.
MappedFile(path, /*writable=*/true) maps MAP_PRIVATE with PROT_READ|PROT_WRITE — the
writable flag exists for backends that fold weights in place after load, e.g. parakeet's
batch-norm-into-conv — and macOS resolves the private copy as each page faults in. So merely
reading the weights privatizes them.

A standalone probe that reads one byte per page and writes nothing anywhere in the program takes a
658 MB GGUF to:

mapped file   [ 658.7M  658.7M  658.7M ]  rw-/rwx SM=COW   <model>.gguf
                VIRT    RESID    DIRTY

The control is the same file mapped MAP_SHARED | PROT_READ and handed to
ggml_backend_dev_buffer_from_host_ptr:

mapped file   [ 658.7M  658.7M      0K ]  r--/r-x SM=SHM   <model>.gguf

— fully resident, zero dirty, and still zero after the backend buffer is freed. Dirty private
pages can be compressed or swapped, never dropped, so the cost is real memory for the life of the
process. The mapping mode is the whole cause. A read-only consumer that never folds weights could
take the MAP_SHARED|PROT_READ path instead and would not need any of this; that option is worth
knowing about but it is not what the loader does today.

The CPU mmap path was never affected — its mmap_buffer_free calls munmap. This is
Apple-silicon-specific, or more precisely specific to any device advertising
buffer_from_host_ptr.

The fix

core_gguf::release_weight_buffer(ggml_backend_buffer_t&): take-and-erase the side-map entry in one
critical section, free the backend buffer, then unmap, then null the caller's handle.

  • Take before free, not after. Between a free and a later erase, a concurrent load could receive
    a new buffer at the same address and register it, and the erase would drop a live mapping's record.
  • Unmap after the free, so a device-side view never outlives its pages. This inherits ggml's
    existing caller contract rather than adding one — ggml_metal_buffer_free already vm_deallocates
    the host pages of a buffer it owns, so "no work referencing this buffer may still be in flight" is
    a precondition every caller already meets.
  • A missing entry is the normal case, not an error: the CPU mmap path and the legacy alloc+copy
    path register nothing and reach the same call.

No vtable entry is touched. An earlier attempt replaced iface.free_buffer on the inner buffer,
reasoning that context and every other method stay Metal's own. That is wrong: that pointer is
Metal's type tag. ggml_backend_buffer_is_metal classifies a buffer by comparing iface.free_buffer
against the shared and private Metal free callbacks (ggml-metal.cpp:176-179), and these buffers are
built with ggml_backend_metal_buffer_shared_i, so a substitution would unclassify every weight
buffer. This fork uses the same pointer as a type tag itself, in mmap_advise_random.

If you would prefer a universal hook, the way to make one safe is to classify a Metal buffer by
its buft — the idiom ggml_backend_metal_device_supports_buft already uses — after which a vtable
shim releases the mapping for every model at once and needs no per-backend change. That is a
ggml-metal change rather than a CrispASR one, so it is offered here rather than included.

A second leak on the same page

load_weights_split allocates a partition above the 1.5 GiB chunk limit as several backend buffers,
putting the first in WeightLoad::buf/::buf_cpu and the rest in ::split_bufs, documented as the
caller's to free. All eighteen backends that call it drop the vector — each moves buf and
buf_cpu into its model struct and lets the local WeightLoad die. free_weights() frees them
correctly and is reached by none of those eighteen. The chunk limit is unconditional, so this is not
only the AMD Vulkan case it was written for.

Ownership moved into the loader: the overflow chunks are keyed to the first buffer of their own
partition and released with it, which fixes all eighteen without touching any of them.
split_bufs remains as a read-only listing of how a load was partitioned. Happy to invert this if
you would rather keep caller ownership and patch the eighteen call sites instead — but a contract
that every caller has ignored seemed like the wrong place for the obligation.

Third leak: a rejected GGUF

The GPU bounds-check rejection set out.buf = nullptr and fell through to the legacy loader,
abandoning the backend buffer and the whole-file mapping registered against it — neither freed nor
unmapped nor erased. That branch is reached by a truncated or crafted GGUF, so it was reachable on
demand by untrusted input. It now releases.

gguf_loader.cpp contains exactly one ggml_backend_buffer_free call, inside
release_weight_buffer.

Call-site conversion

136 sites across 90 files. The set was derived by provenance, not by name: a free is converted only
where its handle was assigned from a WeightLoad field, and never where the same expression is also
assigned from ggml_backend_alloc_ctx_tensors / alloc_buffer / buft_alloc_buffer. That second
rule keeps kv_buf, buf_perm, cross_kv_buf, fused_buf, bake_buf and buf_f32 out. Every
case where the two rules disagreed was checked by hand — titanet's g.buf, moss_tts_codec's and
moss_tts_local_codec's w_buf, and indextts's beam-pool buf are separately allocated and stay
as they were.

Five classes escape per-file analysis and were handled directly:

escape where how it was caught
destructor whose load lives in a sibling translation unit moonshine-impl.h by hand
unqualified call through using namespace core_gguf voxcpm2_tts.cpp by hand
duplicated copy of a src/ file crisp_punc/{fireredpunc,pcs}.cpp tests/test-copies-in-sync.cpp
duplicated copy of a src/ file crisp_lid/lid_cld3.cpp tests/test-copies-in-sync.cpp
a shared library that is neither under src/ nor a duplicate of anything there crisp_audio/src/audio_tower.cpp nothing — see below

crisp_audio is the one that got away, and it is worth saying how. It is outside src/, so the
qualified-call scan never looked at it; and it has no src/ twin, so test-copies-in-sync.cpp
which enumerates {crisp_punc, crisp_lid, crisp_truecase} and compares each file against a src/
copy — structurally could not see it either. It surfaced on a re-run of the provenance rule over
every tracked C/C++ file rather than over src/: all 1201 of them outside ggml/ and third_party/,
intersecting "assigned from a WeightLoad field" with "passed to ggml_backend_buffer_free". That
sweep returns exactly two hits — crisp_audio/src/audio_tower.cpp:688 and a trailing-name collision
in titanet (ctx->g.buf freed, ctx->buf loaded) that the by-hand list above already cleared.

crisp_audio has two in-tree consumers, qwen3_asr and higgs_stt, and one out-of-tree one,
CrispEmbed's BidirLM-Omni audio path.

Left unconverted, verified rather than assumed: the seven backends that already free through
core_gguf::free_weights; crispasr.cpp, whose Whisper loader allocates a backend-owned buffer with
ggml_backend_alloc_ctx_tensors_from_buft and never maps; and crispasr_vad_encdec.cpp,
marblenet_vad.cpp, miotts.cpp, omnivoice.cpp and core/{attention,dac_decoder,fastconformer}.h,
whose buffers come from their own allocations.

A guard worth considering, not included here

The crisp_audio miss is the second time a name- or directory-scoped scan has been the thing that
failed, and neither the compiler nor any existing test can see it: ggml_backend_buffer_free on a
loader buffer is a legal call that compiles and runs.

The check that would catch it is mechanical, and it is the same rule the conversion above was derived
from, run as a test rather than once by hand. For every tracked production source file: collect the
expressions assigned from a WeightLoad field, collect the expressions passed to
ggml_backend_buffer_free, and fail on any name in both. Two details are what make it worth having
rather than a thing that passes while seeing nothing:

  • It declares its own vocabulary and fails outside it. Every appearance of <wl>.buf /
    .buf_cpu must be classified into a form the scan understands — an assignment whose left side it
    captures, or a recognised read (free_weights, release_weight_buffer, a comparison). Anything
    else fails the test rather than being skipped. That is what would have covered the
    using namespace core_gguf spelling and the sibling-translation-unit destructor, both of which a
    plain qualified-name grep misses silently.
  • Its file set is enumerated, and cross-checked against the tree — the shape
    test-copies-in-sync.cpp already uses for its pair list. A new top-level directory carrying C++
    that includes core/gguf_loader.h and is not listed fails the test. That is exactly the gap
    crisp_audio/ fell through.

It would need normalising ctx.model_buf against ctx->model_buf, and the titanet trailing-name
collision above says the comparison has to be on the whole member chain, not the last component.
Left out of this PR to keep it to the leak and its fix.

Tests

Three new/extended files, each failing before the change and passing after. None needs a model file,
a download, or a GPU to build.

file what it pins
tests/test-gguf-release.cpp the entry point's contract on paths needing no GPU: the CPU mmap path, the legacy alloc+copy path (where no entry was ever registered and the call must still be a clean free), repeat calls, the null handle. Runs under both CRISPASR_GGUF_MMAP=1 and =0.
tests/test-gguf-mapping-released.cpp after the free, no region of the process may name the weight file. Exact rather than a footprint threshold: nothing to settle, nothing to poll. Pre-fix the zero-copy case leaves 1 region and the twenty-cycle case leaves 20. Self-skips where no device advertises buffer_from_host_ptr; the CPU-mmap leg still runs on the Linux unit tier.
tests/test-gguf-bounds.cpp extended for the abandoned-buffer path above — a rejected load must leave no mapping.
tests/test-gguf-split-alloc.cpp rewritten around a real chunked load. CRISPASR_GGUF_MAX_ALLOC_CHUNK lowers the 1.5 GiB limit so an eight-tensor synthetic model chunks at 1 KiB — that branch previously needed a multi-gigabyte allocation, which is why it had no coverage and why the leak survived.
tests/test-crisp-audio-mapping-released.cpp the same region oracle one level out: that crisp_audio's own handle reaches the release, driven through the public C API on a synthetic one-layer tower. Pre-fix a single crisp_audio_init_from_file / crisp_audio_free leaves 1 region, five cycles leave 5, and a GGUF that maps and is then rejected for missing tower tensors leaves 1; post-fix all three are 0. The CPU leg passes either way and says so in-file — it is there so the case is not a pure skip where no device advertises buffer_from_host_ptr.

tests/test-region-probe.h holds the region probe. One note in case it is useful elsewhere:
proc_regionfilename alone is not usable for this. Asked about the base of an anonymous region
it answers with the file of the next region at or above that address, so an unrelated 67 MB anonymous
neighbour was counted as a mapping of the weight file. The probe reads PROC_PIDREGIONPATHINFO,
which returns the region and its path in one record. Linux uses /proc/self/maps; elsewhere the
cases report unsupported and skip.

Deliberately not proposed: a footprint- or RSS-growth test. It samples a whole-process figure, needs
a threshold, and would be the flaky case in this suite. The region check answers the same question
exactly. The split-chunk case says in-file what it cannot prove — that the memory came back — since
ggml exposes no per-backend allocation counter.

Verification

  • ctest --test-dir build -L unit -E live: 1606 passed.

  • clang-format 18: clean. cppcheck: clean (the one report at qwen3_tts.cpp:3032 is pre-existing
    and only appears on cppcheck ≥ 2.17; CI's 2.7 does not produce it). clang-tidy: no new findings
    beyond the file's existing readability-braces-around-statements style.

  • A downstream Rust consumer's weights-present suite — Omni CTC 1B/3B/7B, Whisper, the wav2vec2/MMS
    forced aligner, pyannote diarization, enhancement — 2059 passed / 0 failed, byte-identical
    results and identical goldens before and after.

  • Memory, same machine and build, tests serial, macOS phys_footprint:

    before CRISPASR_GGUF_MMAP=0 after
    Omni 3B q8 + 7B q8, one process 11.245 GB 7.734 GB 7.739 GB
    four forced-aligner sessions 2.901 GB 1.367 GB 1.368 GB

    Releasing the mapping costs no wall clock — a 500-test run measured 154.6 s against 157.7 s.

The CrispEmbed half

core/gguf_loader.{h,cpp} exists in both CrispASR and CrispEmbed, and the header records a run of
commits spent recovering from drift between them. tests/test-copies-in-sync.cpp guards fourteen
duplicated files but compares paths within one checkout, so it structurally cannot see that pair —
and a new public core_gguf function is exactly the kind of change that drifts.

This PR does not stand alone: merging it without the CrispEmbed patch breaks that build.
CrispEmbed compiles three shared libraries out of this repo — crisp_audio, crisp_punc and
crisp_lid — against its own copy of the loader header, and all three now call
release_weight_buffer. Checked rather than reasoned about: with the CrispEmbed patch reverted, a
CrispEmbed build stops at crisp_audio/src/audio_tower.cpp:692 and crisp_lid/src/lid_cld3.cpp:807
with no member named 'release_weight_buffer' in namespace 'core_gguf'.

The companion PR is mculbert/CrispEmbed, branch fix/gguf-release-weight-buffer. It is not a copy
of this change — CrispEmbed's loader is a different lineage. There, the no-copy path is opt-in
(load_weights(..., try_mmap=false) by default), the mapping already lives in
WeightLoad::mmap_addr/mmap_len, and free_weights already unmaps, so CrispEmbed has no live
instance of this leak
: its only two try_mmap callers keep the whole WeightLoad. What that patch
does is add release_weight_buffer with the mapping keyed to the buffer instead of only to the
WeightLoad, so the name means the same thing in both repos and the shared sources are correct
whichever header they compile against. It also closes a latent hole on that side — eleven CrispEmbed
models move wl.buf into their own struct and free it directly, each of which would leak the moment
try_mmap were switched on for it.

Its own tests/test_gguf_loader_mmap.cpp gained the region check: 1 mapping while loaded, 0 after
free_weights.

Authored by Claude Opus 5.

mculbert and others added 4 commits August 12, 2026 09:12
core_gguf::load_weights maps the whole GGUF and hands the region to the
device on the zero-copy path. `buffer_from_host_ptr` has no deallocator
parameter, and Metal passes `deallocator:nil`, so freeing the backend
buffer released the device-side view and left the host mapping in place.
The loader dropped its only handle to a region it still owned, and the
side map that recorded it had no caller on any release path.

The comment being deleted argued this was affordable because the kernel
can still evict file-backed pages under pressure. That is false for this
mapping. It is created MAP_PRIVATE with PROT_READ|PROT_WRITE — MappedFile's
`writable` parameter, which exists for backends that fold weights in place
after load — so macOS resolves the copy as each page faults in and merely
*reading* the weights privatizes them. A standalone probe that reads one
byte per page and writes nothing takes a 658 MB GGUF to 658.7M resident /
658.7M dirty, with Metal not involved; the control, the same file mapped
MAP_SHARED|PROT_READ and handed to ggml_backend_dev_buffer_from_host_ptr,
stays at zero dirty pages through the same handoff and after the backend
buffer is freed. Dirty private pages can be compressed or swapped, never
dropped, so the cost is real memory for the life of the process.

Measured on an M4 Max, one process, tests run serially: two Omni CTC
sessions (3B q8 then 7B q8) peaked at 11.25 GB, the sum of both models,
against 7.73 GB with the mmap loader disabled. Accumulation is per *load*,
not per file — twenty load/free cycles of one GGUF left twenty mappings —
so repeated loads of a single weight accumulate exactly like distinct ones.
A downstream suite's four forced-aligner sessions cost 2.90 GB against
1.37 GB. Both figures now match the mmap-disabled run to within 0.07%.

Add core_gguf::release_weight_buffer(): take-and-erase the side-map entry
in one critical section, free the backend buffer, then unmap. Taking the
entry before the free is what makes a double release safe and stops a
concurrent load whose fresh buffer lands on the same address from having
its record erased. Unmapping after the free keeps a device-side view from
outliving its pages, which is the precondition ggml already imposes —
ggml_metal_buffer_free vm_deallocates the host pages of a buffer it owns.
No vtable entry is touched: ggml_backend_buffer_is_metal classifies a
buffer by comparing iface.free_buffer against Metal's own callbacks, so
substituting that pointer would have unclassified every weight buffer.

free_weights and all six of the loader's failure paths route through the
new entry point, including the GPU bounds-check rejection, which abandoned
a registered buffer entirely — neither freed nor unmapped nor erased. That
branch is reached by a truncated or crafted GGUF, so it was a leak an
untrusted input could trigger on demand. gguf_loader.cpp now contains one
ggml_backend_buffer_free call, inside release_weight_buffer.

Converted here: omniasr and the wav2vec2/MMS aligner, the two backends
this was measured against. The fork's other GGUF consumers still free
their weight buffers directly and keep the leak until they are converted.

Tests, each failing before this change and passing after:

  test-gguf-release          the entry point's contract on paths needing
                             no GPU — the CPU mmap path, the legacy
                             alloc+copy path where no entry was ever
                             registered, repeat calls, the null handle.
  test-gguf-mapping-released after free, no region of the process may name
                             the weight file. Exact rather than a footprint
                             threshold: nothing to settle, nothing to poll.
                             Pre-fix the GPU case leaves 1 region and the
                             twenty-cycle case leaves 20.
  test-gguf-bounds           extended for the abandoned-buffer path above.

The region probe reads PROC_PIDREGIONPATHINFO so the region and its path
come from one record; proc_regionfilename alone answers with the file of
the next region at or above the address, which counts an unrelated
anonymous neighbour as a mapping of the weight file.

Note for the CrispEmbed copy: core/gguf_loader.{h,cpp} exists in both
repos and tests/test-copies-in-sync.cpp compares paths within one checkout,
so it cannot see this pair. This adds a public core_gguf function and needs
the matching patch there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release entry point only helps callers that use it. The previous
commit converted omniasr and the wav2vec2 aligner; this converts the
rest, so a fork-wide load no longer leaves the weight file mapped.

135 call sites across 89 files. The set was derived by provenance, not by
name: a free is converted only where its handle was assigned from a
WeightLoad field, and never where the same expression is also assigned
from ggml_backend_alloc_ctx_tensors / alloc_buffer / buft_alloc_buffer.
That second rule is what keeps compute and KV-cache buffers out — the
converted names are buf, buf_w, buf_cpu, buf_w_cpu, w_buf and friends,
while kv_buf, buf_perm, cross_kv_buf, fused_buf, bake_buf and buf_f32 are
left alone. Every case where the two rules disagreed was checked by hand:
titanet's g.buf, moss_tts_codec's and moss_tts_local_codec's w_buf and
indextts's beam-pool buf are all separately allocated and stay as they
were.

Four cases per-file provenance cannot see, handled directly:

  moonshine-impl.h    frees buf_w / buf_w_cpu for a load that happens in
                      moonshine.cpp, so the header had no load_weights
                      call to trace. Now includes core/gguf_loader.h.
  voxcpm2_tts.cpp     calls load_weights_filtered through a
                      `using namespace core_gguf`, so the qualified-call
                      scan did not match it.
  crisp_punc/src/{fireredpunc,pcs}.cpp and crisp_lid/src/lid_cld3.cpp
                      are second copies of files under src/, kept in sync
                      by tests/test-copies-in-sync.cpp. A src-only sweep
                      left them behind and that test caught it.

Backends left unconverted, verified rather than assumed: the seven that
already free through core_gguf::free_weights, which the previous commit
routed through the release path; crispasr.cpp, whose Whisper loader
allocates a backend-owned buffer with
ggml_backend_alloc_ctx_tensors_from_buft and never maps; and
crispasr_vad_encdec.cpp, marblenet_vad.cpp, miotts.cpp, omnivoice.cpp and
core/{attention,dac_decoder,fastconformer}.h, whose buffers come from
their own allocations rather than from this loader.

ctest -L unit: 1602 passed. A downstream suite that exercises Omni CTC,
Whisper, the forced aligner, diarization and enhancement passes with
identical results and identical goldens before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
load_weights_split() allocates a partition larger than 1.5 GiB as several
backend buffers. The first went into WeightLoad::buf / ::buf_cpu and the
rest into ::split_bufs, documented as the caller's to free.

No caller ever did. All eighteen backends that call load_weights_split
move buf and buf_cpu into their own model struct and let the local
WeightLoad die with the vector still in it, so every overflow chunk was
leaked for the life of the process — up to 1.5 GiB each, on any backend,
not only the AMD Vulkan case the chunking was written for. free_weights()
freed them correctly and is reached by none of those eighteen.

An obligation that no caller has honoured is in the wrong place, so the
overflow chunks are now owned by the loader, keyed to the first buffer of
their own partition and released with it by release_weight_buffer(). That
fixes all eighteen without touching any of them. split_bufs still lists
the chunks so a caller can see how a load was partitioned, and is now
documented read-only; free_weights() clears it instead of freeing it,
since freeing there would be a double free.

Reaching the chunked branch used to need a multi-gigabyte allocation,
which is why it had no test and why this went unnoticed.
CRISPASR_GGUF_MAX_ALLOC_CHUNK lowers the limit, so a synthetic
eight-tensor model chunks at 1 KiB and the path runs in the unit tier. The
new case asserts the overflow really happened before asserting anything
about the release — without that control it would pass having never
chunked — and that a second release is a no-op rather than a double free.
What it does not prove is that the memory came back: ggml exposes no
per-backend allocation counter to assert against, and a footprint
threshold would be the flaky case in this suite.

The hand-populated "free_weights clears split_bufs" case is replaced by
that end-to-end one. It pushed buffers the loader had never issued into
split_bufs, which is exactly the use the field no longer supports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fork-wide conversion missed one consumer. crisp_audio's load_model()
moves core_gguf::load_weights' buffer into crisp_audio_context::model_buf,
and crisp_audio_free() destroyed it with ggml_backend_buffer_free() — so on
a device advertising buffer_from_host_ptr the device view went away and the
whole-file mapping stayed resident and dirty for the process's life. Same
leak the previous two commits fixed everywhere else.

crisp_audio/ escaped the earlier sweep because it is neither under src/ nor
a duplicate of anything under src/: the qualified-call scan looked in src/,
and tests/test-copies-in-sync.cpp — which caught the crisp_punc/ and
crisp_lid/ copies — enumerates {crisp_punc, crisp_lid, crisp_truecase} and
compares against a src/ twin that crisp_audio has none of. So it is a fifth
class of escape alongside the four the last commit listed, not an instance
of any of them.

Re-ran the provenance rule over all 1201 tracked C/C++ files outside ggml/,
third_party/ and the untracked bindings/ruby/ext/sources/ copy, intersecting
"assigned from a WeightLoad field" with "passed to ggml_backend_buffer_free":
this site and one trailing-name collision in titanet (ctx->g.buf freed,
ctx->buf loaded; g.buf is alloc_ctx_tensors and was already checked by hand).
Nothing else. The three headers cleared by assertion last time —
core/{attention,dac_decoder,fastconformer}.h — allocate locally via
buft_alloc_buffer / alloc_ctx_tensors, so no cross-file escape hides there.

Two shipped consumers reach the fixed path in-tree: qwen3_asr and higgs_stt
both link crisp_audio (src/CMakeLists.txt:1171, :1337). CrispEmbed's
BidirLM-Omni audio path links the same library and needs the matching
core/gguf_loader.{h,cpp} sync.

The failure path shares the fix: crisp_audio_init_from_file calls
crisp_audio_free when load_model returns false, so a GGUF that maps and is
then rejected for missing tower tensors leaked the same way.

tests/test-crisp-audio-mapping-released.cpp drives the public C API on a
synthetic one-layer tower — no model file, no download — and asks the kernel
which regions still name the GGUF. Pre-fix: 1 leaked region on a single
init/free, 5 across five cycles (per-load accumulation, reproduced one level
up from the loader), 1 on the rejected load. Post-fix: 0 on all three. The
CPU leg passes either way, as the file states, and is there so the case is
not a pure skip on hosts with no host-pointer GPU.

ctest -L unit -E live: 1606 passed, 0 failed. clang-format 18 clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CrispStrobe

Copy link
Copy Markdown
Owner

Reviewed and validated locally on Apple Silicon against the current claimed main.

Validation:

  • PR-specific suites passed: 28 + 37 + 19 + 188 + 121 assertions.
  • Full unit gate after building optional VAD/Metal targets: 35/35 passed (2 documented platform skips).
  • Real Metal mapping-release tests passed, including repeated load/free and crisp_audio consumer paths.
  • Real model-backed live check passed with moonshine-tiny-q4_k.gguf on both CPU mmap and Metal zero-copy paths; transcription matched.
  • clang-format and diff checks clean.

No regression or additional fix was found. The companion CrispEmbed branch mculbert/CrispEmbed@fix/gguf-release-weight-buffer exists and should land for cross-repository header/source parity.

@CrispStrobe
CrispStrobe merged commit 4e17c69 into CrispStrobe:main Aug 13, 2026
25 checks passed
@CrispStrobe

Copy link
Copy Markdown
Owner

Companion landed in CrispEmbed via #47, merge commit 4ca16c08.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants